"""
A blocking queue is a data structure that provides thread-safe operations for adding and removing elements. It allows multiple threads to interact with the queue in a synchronized manner, providing blocking operations when the queue is full or empty.

Properties:
- A blocking queue is typically bounded, meaning it has a maximum capacity specified during initialization.
- When the blocking queue is full and a thread attempts to add an element, it blocks until space becomes available.
- When the blocking queue is empty and a thread attempts to remove an element, it blocks until an element is available.

Operations:

1. Initialization:
   - `BlockingQueue(max_size)`: Creates an empty blocking queue with the specified maximum size.

2. Basic Operations:
   - `put(item)`: Inserts an element into the blocking queue, blocking if the queue is full.
   - `get()`: Removes and returns an element from the blocking queue, blocking if the queue is empty.
   - `peek()`: Returns the element at the front of the blocking queue without removing it.
   - `is_empty()`: Checks if the blocking queue is empty.
   - `is_full()`: Checks if the blocking queue is full.
   - `size()`: Returns the number of elements in the blocking queue.

Example Usage:
```
queue = BlockingQueue(5)

queue.put(3)
queue.put(7)
queue.put(1)
print(queue.get())  # Output: 3
print(queue.peek())  # Output: 7
print(queue.size())  # Output: 2
print(queue.is_empty())  # Output: False
print(queue.is_full())  # Output: False
```

Time and Space Complexity:
- The put and get operations have a time complexity of O(1), as they involve adding or removing elements from the queue.
- Peeking at the front element can be done in constant time.
- Checking if the blocking queue is empty or full, as well as obtaining its size, can be done in constant time.
- The space complexity of a blocking queue depends on the maximum capacity specified during initialization.
"""

import threading
import time


class BlockingQueue:
    """
    A simple implementation of a blocking queue.

    This blocking queue class supports enqueue, dequeue, peek, is_empty, is_full,
    clear, size, and get_capacity operations.

    Attributes:
        capacity (int): The maximum capacity of the blocking queue.
    """

    def __init__(self, capacity: int = 10):
        """
        Initialize the blocking queue.

        Args:
            capacity (int, optional): The maximum capacity of the blocking queue. Defaults to 10.
        """
        self._data = [None] * capacity
        self._capacity = capacity
        self._front = 0
        self._rear = -1
        self._size = 0
        self._lock = threading.Lock()
        self._not_full = threading.Condition(self._lock)
        self._not_empty = threading.Condition(self._lock)

    def __len__(self) -> int:
        """Return the number of elements in the blocking queue."""
        return self._size

    def __repr__(self):
        """Return a string representation of the blocking queue."""
        return f'BlockingQueue(capacity={self._capacity}, data={self._data})'

    def __str__(self):
        """Return a formatted string representation of the blocking queue."""
        return str(self._data)

    def enqueue(self, item: object) -> bool:
        """
        Add an item to the blocking queue.

        If the queue is full, the enqueue operation will be blocked until space is available.

        Args:
            item (object): The item to be added.

        Returns:
            bool: True if the enqueue operation was successful, False otherwise.
        """
        with self._not_full:
            while self._size == self._capacity:
                self._not_full.wait()
            self._rear = (self._rear + 1) % self._capacity
            self._data[self._rear] = item
            self._size += 1
            self._not_empty.notify()
            return True

    def dequeue(self) -> object:
        """
        Remove and return the item from the blocking queue.

        If the queue is empty, the dequeue operation will be blocked until an item is available.

        Returns:
            object: The item from the blocking queue.
        """
        with self._not_empty:
            while self._size == 0:
                self._not_empty.wait()
            item = self._data[self._front]
            self._data[self._front] = None
            self._front = (self._front + 1) % self._capacity
            self._size -= 1
            self._not_full.notify()
            return item

    def peek(self) -> object:
        """
        Return the item from the front of the blocking queue without removing it.

        Returns:
            object: The item from the front of the blocking queue.

        Raises:
            IndexError: If the blocking queue is empty.
        """
        with self._lock:
            if self._size == 0:
                raise IndexError('Blocking queue is empty')
            return self._data[self._front]

    def is_empty(self) -> bool:
        """Check if the blocking queue is empty."""
        with self._lock:
            return self._size == 0

    def is_full(self) -> bool:
        """Check if the blocking queue is full."""
        with self._lock:
            return self._size == self._capacity

    def clear(self):
        """Clear all elements from the blocking queue."""
        with self._lock:
            self._data = [None] * self._capacity
            self._front = 0
            self._rear = -1
            self._size = 0

    def size(self) -> int:
        """Return the current size of the blocking queue."""
        with self._lock:
            return self._size

    def get_capacity(self) -> int:
        """Return the maximum capacity of the blocking queue."""
        return self._capacity


if __name__ == "__main__":
    blocking_queue = BlockingQueue(5)  # Initialize a blocking queue with capacity 5

    def producer():
        for i in range(1, 6):
            blocking_queue.enqueue(i)
            print(f"Producer enqueued: {i}")
            time.sleep(1)

    def consumer():
        while True:
            item = blocking_queue.dequeue()
            print(f"Consumer dequeued: {item}")
            time.sleep(2)

    # Start producer and consumer threads
    producer_thread = threading.Thread(target=producer)
    consumer_thread = threading.Thread(target=consumer)
    producer_thread.start()
    consumer_thread.start()

    # Wait for threads to finish
    producer_thread.join()
    consumer_thread.join()
